Security

Two-Factor Authentication

Structr supports two-factor authentication (2FA) using the TOTP (Time-Based One-Time Password) standard. When enabled, users must provide a code from an authenticator app in addition to their password. This adds a second layer of security that protects accounts even if passwords are compromised.

TOTP is compatible with common authenticator apps like Google Authenticator, Microsoft Authenticator, Authy, and others.

Prerequisites

Because TOTP relies on synchronized time, ensure that both the Structr server and users’ mobile devices are synced to an NTP server. Time drift of more than 30 seconds can cause authentication failures.

Configuration

Configure two-factor authentication in structr.conf or through the Configuration Interface.

Application Settings

Setting Default Description
security.twofactorauthentication.level 1 Enforcement level: 0 = disabled, 1 = optional (per-user), 2 = required for all users
security.twofactorauthentication.issuer Structr The issuer name displayed in authenticator apps
security.twofactorauthentication.algorithm SHA1 Hash algorithm: SHA1, SHA256, or SHA512
security.twofactorauthentication.digits 6 Code length: 6 or 8 digits
security.twofactorauthentication.period 30 Code validity period in seconds
security.twofactorauthentication.logintimeout 300 Time window in seconds to enter the code after password authentication
security.twofactorauthentication.loginpage /twofactor Application page for entering the two-factor code
security.twofactorauthentication.devicetrust.enabled false Enables or disables users to trust the browser they are logging in with
security.twofactorauthentication.devicetrust.signingsecret Secret key that signs device trust tokens (auto-generated if not set manually)
security.twofactorauthentication.devicetrust.duration 30 Trust period in days for trusted browsers
security.twofactorauthentication.devicetrust.cookiename dt_token Name of the cookie that stores the device trust token

Note: Changing algorithm, digits, or period after users have already enrolled invalidates their existing authenticator setup. Set twoFactorConfirmed = false on affected users so they receive a new QR code on their next login.

Enforcement Levels

The level setting controls how two-factor authentication applies to users:

Level Behavior
0 Two-factor authentication is completely disabled
1 Optional - users can enable 2FA individually via the isTwoFactorUser property
2 Required - all users must use two-factor authentication

User Properties

Four properties on the User type control two-factor authentication:

Property Type Description
isTwoFactorUser Boolean Enables two-factor authentication for this user. Only effective when level is set to 1 (optional).
twoFactorConfirmed Boolean Indicates whether the user has completed two-factor setup. Automatically set to true after first successful 2FA login. Set to false to force re-enrollment.
twoFactorSecret String The secret key used to generate TOTP codes. Automatically generated when the user first enrolls.
deviceTrustSecret String The secret that is used to identify the user for a stored trust token. Can be used to revoke existing trust tokens for a user by calling user.rotateDeviceTrustSecret()

Authentication Flow

The basic two-factor login process works as follows:

  1. User submits username and password to /structr/rest/login
  2. If credentials are valid and 2FA is enabled, Structr returns HTTP status 202 (Accepted)
  3. The response headers contain a temporary token and, for first-time setup, QR code data
  4. User scans the QR code with their authenticator app (first time only)
  5. User enters the 6-digit code from their authenticator app
  6. User submits the code with the temporary token to /structr/rest/login
  7. If the code is valid, Structr creates a session and returns HTTP status 200

Wrong Codes

A wrong code counts against the same budget a wrong password counts against,
security.passwordpolicy.maxfailedattempts. When that budget is used up the temporary token is
discarded, so the next attempt has to start again at step 1 with the password — and that step refuses
an account whose failed attempts are over the limit. Six digits with unlimited guesses would otherwise
make the second factor a delay rather than a factor.

Clear passwordAttempts on the user to lift a lockout.

The Secret is Re-Issued on Every Enrolment

Step 3 hands out the secret, and the only thing needed to get that far is the password. So every time
a QR code is issued for a user who has not confirmed yet, the secret behind it is generated anew and
the previous one stops working. Two people who both know the password can therefore never end up with
the same working secret: whoever asked last is the only one who can complete step 5.

For the user this means a QR code has to be scanned in the same login it was shown in. Starting the
login again shows a new QR code, and an authenticator entry from an earlier attempt no longer matches.

A confirmed user’s secret is never touched — it lives in their authenticator app.

Login Paths That Cannot Ask for a Code

An OAuth return, a registration confirmation link and a password reset link all identify a user
without ever asking for a code. Where the configuration requires a second factor, none of them creates
a session: the browser is redirected to security.twofactorauthentication.loginpage with a token
parameter, and the login is finished there exactly as in step 6 above.

These paths do not enrol. They carry no QR code — it would have to travel as a URL parameter, which
does not fit in a redirect — so a user who has not confirmed a second factor yet has to log in with
their password once, which is the flow that can enrol them.

Implementation

To implement two-factor authentication in your application, you need two pages: a login page and a two-factor code entry page.

Login Page

Create a login form that detects the two-factor response. When the server returns status 202, redirect to the two-factor page with the token, deviceTrustPossible and optional QR data as URL parameters.

JavaScript:

async function login(username, password) {
	const response = await fetch('/structr/rest/login', {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json'
		},
		body: JSON.stringify({
			name: username,
			password: password
		})
	});

	if (response.status === 202) {
		// Two-factor authentication required
		const token = response.headers.get('token');
		const qrdata = response.headers.get('qrdata') || '';
		const twoFactorPage = response.headers.get('twoFactorLoginPage');
		const deviceTrustPossible = response.headers.get('deviceTrustPossible');
		const deviceTrustDuration = response.headers.get('deviceTrustDuration');
		
		window.location.href = `${twoFactorPage}?token=${token}&qrdata=${qrdata}&deviceTrustPossible=${deviceTrustPossible}&deviceTrustDuration=${deviceTrustDuration}`;
	} else if (response.ok) {
		// Login successful, no 2FA required
		window.location.href = '/';
	} else {
		// Login failed
		const error = await response.json();
		console.error('Login failed:', error);
	}
}

curl:

curl -si http://localhost:8082/structr/rest/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"name": "user", "password": "password"}'

When two-factor authentication is required, the response looks like:

HTTP/1.1 202 Accepted
token: eyJhbGciOiJIUzI1NiJ9...
twoFactorLoginPage: /twofactor
deviceTrustPossible: true
deviceTrustDuration: 30
qrdata: iVBORw0KGgoAAAANSUhEUgAA...

The response headers contain:

Header Description
token Temporary token for the two-factor login (valid for the configured timeout period)
twoFactorLoginPage The configured page for entering the two-factor code
deviceTrustPossible If device trust is possible according to the configuration
deviceTrustDuration Trust duration in days
qrdata Base64-encoded PNG image of the QR code (only present if twoFactorConfirmed is false for the user)

Two-Factor Page

Create a page that displays the QR code for first-time setup and accepts the TOTP code.

Example HTML Structure

<!DOCTYPE html>
<html>
<head>
	<title>Two-Factor Authentication</title>
</head>
<body>
	<h1>Two-Factor Authentication</h1>

	<div id="setup-instructions" style="display: none;">
		<p>Scan this QR code with your authenticator app:</p>
		<img id="qrcode" alt="QR Code" />
		<p>Then enter the 6-digit code shown in your app.</p>
	</div>

	<form id="twoFactorForm">
		<label for="code">Authentication Code:</label>

		<input type="text" id="code" name="code"
			pattern="[0-9]{6,8}" maxlength="8"
			autocomplete="one-time-code" required />

		<label id="trust-device-wrapper" class="flex items-center" style="display: none;">
			<input type="checkbox" id="trust-device" name="trustDevice" />
			<span>Trust device</span>
		</label>

		<button type="submit">Verify</button>
	</form>

	<p id="error" style="color: red;"></p>

	<script>
		document.addEventListener('DOMContentLoaded', () => {
			const params = new URLSearchParams(location.search);
			const token = params.get('token');
			const qrdata = params.get('qrdata');
			const deviceTrustPossible = params.get('deviceTrustPossible') === 'true';
			const deviceTrustDuration = params.get('deviceTrustDuration');

			// Display QR code for first-time setup
			if (qrdata) {
				const qrImage = document.getElementById('qrcode');
				// Convert URL-safe base64 back to standard base64
				const standardBase64 = qrdata.replaceAll('_', '/').replaceAll('-', '+');
				qrImage.src = 'data:image/png;base64,' + standardBase64;
				qrImage.style.display = 'block';

				document.getElementById('setup-instructions').style.display = 'block';
			}

			if (deviceTrustPossible) {
				document.querySelector('#trust-device-wrapper').style.display = null;
				document.querySelector('#trust-device-wrapper span').textContent += ' for ' + deviceTrustDuration + ' days'; 
			}

			// Handle form submission
			document.getElementById('twoFactorForm').addEventListener('submit', async (event) => {
				event.preventDefault();

				const code = document.getElementById('code').value;
				const trustChecked = document.getElementById('trust-device').checked;

				const response = await fetch('/structr/rest/login', {
					method: 'POST',
					headers: {
						'Content-Type': 'application/json'
					},
					body: JSON.stringify({
						twoFactorToken: token,
						twoFactorCode: code,
						trustDevice: trustChecked
					})
				});

				if (response.ok) {
					window.location.href = '/';
				} else {
					document.getElementById('error').textContent = 'Invalid code. Please try again.';
				}
			});
		});
	</script>
</body>
</html>

curl:

curl -si http://localhost:8082/structr/rest/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"twoFactorToken": "eyJhbGciOiJIUzI1NiJ9...", "twoFactorCode": "123456", "trustDevice": true}'

Managing User Enrollment

Enabling 2FA for a User

When the enforcement level is set to 1 (optional), enable two-factor authentication for individual users by setting isTwoFactorUser to true.

curl:

curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
  -H "Content-Type: application/json" \
  -H "X-User: admin" \
  -H "X-Password: admin" \
  -d '{"isTwoFactorUser": true}'

JavaScript:

await fetch('/structr/rest/User/<UUID>', {
    method: 'PUT',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        isTwoFactorUser: true
    })
});

The user will see the QR code on their next login.

Re-Enrolling a User

To force a user to set up two-factor authentication again (for example, if they lost their phone), set twoFactorConfirmed to false:

curl:

curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
  -H "Content-Type: application/json" \
  -H "X-User: admin" \
  -H "X-Password: admin" \
  -d '{"twoFactorConfirmed": false}'

The user will receive a new QR code on their next login. Their authenticator app will need to be updated with the new secret.

Disabling 2FA for a User

To disable two-factor authentication for a user (when level is 1):

curl:

curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
  -H "Content-Type: application/json" \
  -H "X-User: admin" \
  -H "X-Password: admin" \
  -d '{"isTwoFactorUser": false}'

IP Whitelisting (removed)

Earlier versions could skip the second factor for addresses listed in security.twofactorauthentication.whitelistedIPs. That setting no longer exists. The address it matched was read from the X-Forwarded-For header, which a client sets itself, so anyone who knew a listed address and a password could send that header and log in without a code. Structr logs a warning at startup if the key is still present in structr.conf, and requests from those addresses are asked for a code like any other.

Use Trusted Devices below to spare a known browser the code, or a reverse proxy in front of Structr if access really has to be decided by network address.

Trusted Devices

Device trust functionality can be enabled via the configuration setting security.twofactorauthentication.devicetrust.enabled and configured per-user via the attribute deviceTrustPossible. The login form above auto-adapts and shows a “Trust Device” checkbox stating the configured trust duration.

If the user logs in via 2FA successfully and requests device trust, a trust cookie (security.twofactorauthentication.devicetrust.cookiename) is set for the user’s browser. This browser is then fingerprinted and trusted for the configured number of days (security.twofactorauthentication.devicetrust.duration) and the login requests for that user from that browser proceed with password-authentication only.

The browser fingerprint includes browser name, browser major version, operating system name, operating system major version, and device class. If any of these fields change, the trust cookie becomes invalid.

Disabling device trust (via security.twofactorauthentication.devicetrust.enabled or the per-user attribute) does not invalidate already-issued device trust cookies. It suspends the device trust feature and requires 2FA login even if the user has a valid device trust cookie. If device trust is enabled again, previously issued trust cookies are used again.

Device trust tokens are signed with a global signing secret (security.twofactorauthentication.devicetrust.signingsecret) which is automatically created if none is set. Changing this secret revokes and invalidates all trust cookies for all users.

A user’s trust cookies can be revoked by calling user.rotateDeviceTrustSecret(), which generates a new secret and invalidates all previously issued cookies for that user only.

Troubleshooting

Invalid Code Errors

If users consistently receive “invalid code” errors:

  1. Check time synchronization - The most common cause is time drift between the server and the user’s device. Ensure both are synced to NTP.
  2. Verify the period setting - If you changed security.twofactorauthentication.period, users need to re-enroll.
  3. Check the algorithm - Some older authenticator apps only support SHA1.

Lost Authenticator Access

If a user loses access to their authenticator app:

  1. An administrator sets twoFactorConfirmed = false on the user
  2. The user logs in with username and password
  3. The user scans the new QR code with their authenticator app
  4. The user completes the login with the new code

QR Code Not Displaying

If the QR code does not display:

  1. Check that qrdata is present in the response headers
  2. Verify the base64 conversion (URL-safe to standard)
  3. Ensure the twoFactorConfirmed property is false

Related Topics

  • User Management - User properties and account security
  • JWT Authentication - Token-based authentication
  • OAuth - Authentication with external providers